HTTP Client MockツールのHTTPrettyを使って、requestsの処理を拡張したクラスのテストを書こう
サーモン大好き、横山です。
requestsの拡張クラスを作っていて、実際に動かした際のMockResponseを作成するのが難しいなと思ったことは無いですか?私はあります。 今回はそういうときのためのHTTP Client Mockツール、HTTPrettyを紹介します。
前提の環境
今回は仮想環境に下記のコマンドを叩いてパッケージをインストールした環境です。
$ mkdir -p /path/to/httpretty $ cd /path/to/httpretty/ $ python3 -mvenv venv $ . venv/bin/activate (venv)$ pip install requests pytest pytest-cov httpretty
ファイル準備
今回は実装とテストファイル、テストを実行するbashファイルを用意します。
$ tree -I '__pycache__|venv|htmlcov' . ├── main.py ├── run_test.bash └── tests ├── __init__.py └── test_main.py
import requests TOKEN="salmon_daisuki" class MySession(requests.Session): def __init__(self): super().__init__() self.auth = self.auth_hook def auth_hook(self, request): request.headers["Authorization"] = f"Bearer {TOKEN}" def get_players(session: MySession): return session.get("http://someone.api.com/players") def main(): session = MySession() resp = get_players(session) # .. respを使ってゴニョゴニョ処理をする if __name__ == "__main__": main()
#!/bin/bash pytest -s ./tests --cov=. --cov-report=html
# 空ファイル
class TestMain: def test_any(self): pass
ここまで準備したら一度テストを動かしてみます。
テスト実行
$ bash run_test.bash ============================= test session starts ============================== platform darwin -- Python 3.9.6, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 rootdir: /path/to/httpretty plugins: cov-3.0.0 collected 1 item tests/test_main.py . ---------- coverage: platform darwin, python 3.9.6-final-0 ----------- Coverage HTML written to dir htmlcov ============================== 1 passed in 0.07s ===============================
テストを実行すると、カバレッジ結果をHTMLで見ることが出来ます。HTMLファイルは、main.py
のカバレッジを確認したい場合は、 htmlcov/main_py.html
を開くと見ることができ、他のファイルのカバレッジを確認したい場合は htmlcov/index.html
を開くとファイルの一覧を見ることが出来ます。
以下の画像は、main.pyのカバレッジでテストを全く書いてないので、カバレッジが真っ赤ですね。こちらは想定した結果です。
テストの目的
今回はmain.pyに MySession
クラスにある auth_hook
が session.get
を行ったときに動作するかをテストで確認したいです。
しかし、実際に http://someone.api.com/players
へアクセスしての動作確認はしたくないです。なので、main.pyに設定したURLへはテスト時はアクセスせずに動作を確認したいです。
session.get
をmockして確認した場合
テスト時はアクセスさせたくないので、単純に get_players
の引数のsession変数をmockに差し替えてテストしてみます。
from unittest.mock import Mock from main import get_players class TestMain: def test_get_players_with_mock(self): mock_session = Mock() get_players(mock_session)
こちらのテストを実行したカバレッジがこちら
MySession
のインスタンスを作ったわけでは無いので、 MySession
内の関数は実行されていないことがわかります。get_players
の中身だけ通った感じです。
とはいえ、MySessionのインスタンスを作り、requestsの内部を潜り、関数をmockするのはとても手間がかかると思います。
そこで、HTTPretty を使います。
HTTPrettyとは?
HTTPrettyは、socketとsslモジュールを、HTTPリクエストをTCPコネクションのレベルでmockしてくれるpythonライブラリです。
HTTPretty is a python library that swaps the modules socket and ssl with fake implementations that intercept HTTP requests at the level of a TCP connection.
引用元: https://httpretty.readthedocs.io/en/latest/introduction.html#a-more-technical-description
テストでTCPレベルでmockしたい、httpretty.activate のデコレータをつけます。つけたテストに、 httpretty.register_uri でmockしたいMethod、URL、ResponseBodyを定義します。
from main import TOKEN, MySession, get_players import httpretty import json PLAYERS_DICT=dict(players=[{"name":"clameso"}]) PLAYERS_RESPONSE = json.dumps(PLAYERS_DICT) class TestMain: @httpretty.activate def test_get_players_with_httpretty(self): # setup session = MySession() httpretty.register_uri( "GET", "http://someone.api.com/players", body=PLAYERS_RESPONSE ) except_header = f"Bearer {TOKEN}" # exercise actual = get_players(session) # verify assert actual.json() == PLAYERS_DICT assert actual.request.headers["Authorization"] == except_header
こちらにテストを書き換えてテストを実行して見ると…テストが落ちます。
$ bash run_test.bash ============================================ test session starts ============================================= platform darwin -- Python 3.9.6, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 rootdir: /path/to/httpretty plugins: cov-3.0.0 collected 1 item tests/test_main.py F ================================================== FAILURES ================================================== __________________________________ TestMain.test_get_players_with_httpretty __________________________________ self = <tests.test_main.TestMain object at 0x102e3d850> @httpretty.activate def test_get_players_with_httpretty(self): # setup session = MySession() httpretty.register_uri( "GET", "http://someone.api.com/players", body=PLAYERS_RESPONSE ) except_header = f"Bearer {TOKEN}" # exercise > actual = get_players(session) tests/test_main.py:24: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ main.py:16: in get_players return session.get("http://someone.api.com/players") venv/lib/python3.9/site-packages/requests/sessions.py:555: in get return self.request('GET', url, **kwargs) venv/lib/python3.9/site-packages/requests/sessions.py:528: in request prep = self.prepare_request(req) venv/lib/python3.9/site-packages/requests/sessions.py:456: in prepare_request p.prepare( venv/lib/python3.9/site-packages/requests/models.py:320: in prepare self.prepare_auth(auth, url) _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ self = <PreparedRequest [GET]> auth = <bound method MySession.auth_hook of <main.MySession object at 0x102e3dd00>> url = 'http://someone.api.com/players' def prepare_auth(self, auth, url=''): """Prepares the given HTTP auth data.""" # If no Auth is explicitly provided, extract it from the URL first. if auth is None: url_auth = get_auth_from_url(self.url) auth = url_auth if any(url_auth) else None if auth: if isinstance(auth, tuple) and len(auth) == 2: # special-case basic HTTP auth auth = HTTPBasicAuth(*auth) # Allow auth to make its changes. r = auth(self) # Update self to reflect the auth changes. > self.__dict__.update(r.__dict__) E AttributeError: 'NoneType' object has no attribute '__dict__' venv/lib/python3.9/site-packages/requests/models.py:559: AttributeError ---------- coverage: platform darwin, python 3.9.6-final-0 ----------- Coverage HTML written to dir htmlcov ========================================== short test summary info =========================================== FAILED tests/test_main.py::TestMain::test_get_players_with_httpretty - AttributeError: 'NoneType' object ha... ============================================= 1 failed in 0.31s ==============================================
エラーとなってる r.__dict__
の r
がNoneTypeで dict なんてないよーと言われているので、main.py側の auth_hook
でrequestを返して無いのが原因です。
ですので、実装側を修正します、diffはこんな感じになります。
diff --git a/main.py b/main.py index 982ee87..ab6485d 100644 --- a/main.py +++ b/main.py @@ -10,6 +10,7 @@ class MySession(requests.Session): def auth_hook(self, request): request.headers["Authorization"] = f"Bearer {TOKEN}" + return request
改めてテストを流しますと、テストが成功します。
$ bash run_test.bash ===================================== test session starts ===================================== platform darwin -- Python 3.9.6, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 rootdir: /path/to/httpretty plugins: cov-3.0.0 collected 1 item tests/test_main.py . ---------- coverage: platform darwin, python 3.9.6-final-0 ----------- Coverage HTML written to dir htmlcov ====================================== 1 passed in 0.24s ======================================
カバレッジはこちら。やりたかった auth_hook
が session.get
を行ったときに動作するかをテストが出来ました。
まとめ
外部サービスを利用したコードをテストをするときは、外部にアクセスさせずに標準のMockを用いたテストで意図したことを確認しづらい場合が出てきます。 でも、単体テストに外部サービス、今回はHTTPサーバーにつないでテストを書きたくない……そうしたときにHTTPrettyみたいな、HTTP Client Mockツール使ってみるのはいかがでしょうか。
この記事がだれかのお役に立てば幸いです。